# coding:utf8

import sys

reload(sys)
sys.setdefaultencoding('utf-8')

import json
import re
import sys
import uuid
from collections import defaultdict
from functools import partial

import yaml
from flask import Blueprint
from flask import jsonify, render_template, request, current_app
from flask.views import MethodView
from libapicpts import cpts

from util import print_dict

path_param_regex = re.compile('<(?:[a-zA-Z0-9]+:)?([a-zA-Z0-9]+)>')

OPTIONAL_FIELDS = [
    'tags', 'consumes', 'produces', 'schemes', 'security',
    'deprecated', 'operationId', 'externalDocs'
]


def find_url_rule(method):
    endpoint = None
    for e, m in current_app.view_functions.items():
        if m == method:
            endpoint = e
            break

    if endpoint is None: return None

    app_rules = [
        rule for rule in current_app.url_map.iter_rules()
    ]

    for rule in app_rules:
        if rule.endpoint == endpoint:
            return rule
    else:
        return None


def BR_SANITIZER(text):
    return text.replace('\n', '<br/>') if text else text


def extract_definitions(alist, level=None, endpoint=None, verb=None,
                        prefix_ids=False):
    """
    Since we couldn't be bothered to register models elsewhere
    our definitions need to be extracted from the parameters.
    We require an 'id' field for the schema to be correctly
    added to the definitions list.
    """
    endpoint = endpoint or request.endpoint.lower()
    verb = verb or request.method.lower()
    endpoint = endpoint.replace('.', '_')

    def _extract_array_defs(source):
        """
        Extracts definitions identified by `id`
        """
        # extract any definitions that are within arrays
        # this occurs recursively
        ret = []
        items = source.get('items')
        if items is not None and 'schema' in items:
            ret += extract_definitions(
                [items], level + 1, endpoint, verb, prefix_ids)
        return ret

    # for tracking level of recursion
    if level is None:
        level = 0

    defs = list()
    for item in alist:
        if not getattr(item, 'get'):
            raise RuntimeError('definitions must be a list of dicts')
        schema = item.get("schema")
        if schema is not None:
            schema_id = schema.get("id")
            if schema_id is not None:
                # add endpoint_verb to schema id to avoid conflicts
                if prefix_ids:
                    schema['id'] = schema_id = "{}_{}_{}".format(
                        endpoint, verb, schema_id
                    )
                # ^ api['SWAGGER']['prefix_ids'] = True
                # ... for backwards compatibility with <= 0.5.14

                defs.append(schema)
                ref = {"$ref": "#/definitions/{}".format(schema_id)}
                # only add the reference as a schema if we are in a
                # response or a parameter i.e. at the top level
                # directly ref if a definition is used within another
                # definition
                if level == 0:
                    item['schema'] = ref
                else:
                    item.update(ref)
                    del item['schema']

            # extract any definitions that are within properties
            # this occurs recursively
            properties = schema.get('properties')
            if properties is not None:
                defs += extract_definitions(
                    properties.values(), level + 1, endpoint, verb, prefix_ids)

            defs += _extract_array_defs(schema)

        defs += _extract_array_defs(item)

    return defs


def is_valid_method_view(endpoint):
    """
    Return True if obj is MethodView
    """
    klass = endpoint.__dict__.get('view_class', None)
    try:
        return issubclass(klass, MethodView)
    except TypeError:
        return False


def merge_specs(target, source):
    """
    Update target dictionary with values from the source, recursively.
    List items will be merged.
    """

    for key, value in source.items():
        if isinstance(value, dict):
            node = target.setdefault(key, {})
            merge_specs(node, value)
        elif isinstance(value, list):
            node = target.setdefault(key, [])
            node.extend(value)
        else:
            target[key] = value


def json_to_yaml(content):
    """
    TODO: convert json to yaml
    """
    return content


def cleandoc(doc):
    """Clean up indentation from docstrings.

    Any whitespace that can be uniformly removed from the second line
    onwards is removed."""
    try:
        lines = doc.expandtabs().split('\n')
    except UnicodeError:
        return None
    else:
        # Find minimum indentation of any non-blank lines after first line.
        margin = sys.maxsize
        for line in lines[1:]:
            content = len(line.lstrip())
            if content:
                indent = len(line) - content
                margin = min(margin, indent)
        # Remove indentation.
        if lines:
            lines[0] = lines[0].lstrip()
        if margin < sys.maxsize:
            for i in range(1, len(lines)): lines[i] = lines[i][margin:]
        # Remove any trailing or leading blank lines.
        while lines and not lines[-1]:
            lines.pop()
        while lines and not lines[0]:
            lines.pop(0)
        return '\n'.join(lines)


def getdoc(object):
    """Get the documentation string for an object.

    All tabs are expanded to spaces.  To clean up docstrings that are
    indented to line up with blocks of code, any whitespace than can be
    uniformly removed from the second line onwards is removed."""
    try:
        doc = object.__doc__
    except AttributeError:
        return None

    if not isinstance(doc, str):
        return None
    return cleandoc(doc)


def parse_docstring(obj, process_doc, endpoint=None, verb=None):
    """
    Gets swag data for method/view docstring
    """
    first_line, other_lines, swag = None, None, None

    full_doc = None
    from_file = False

    full_doc = getdoc(obj)

    if full_doc:
        yaml_sep = full_doc.find('---')

        if yaml_sep != -1:
            line_feed = full_doc.find('\n')
            if line_feed != -1:
                first_line = process_doc(full_doc[:line_feed])
                other_lines = process_doc(
                    full_doc[line_feed + 1: yaml_sep]
                )
                swag = yaml.safe_load(full_doc[yaml_sep + 4:])
        else:
            if from_file:
                swag = yaml.safe_load(full_doc)
            else:
                first_line = full_doc

    return first_line, other_lines, swag


def get_specs(rules, ignore_verbs, sanitizer):
    specs = []
    for rule in rules:
        endpoint = current_app.view_functions[rule.endpoint]
        methods = dict()
        is_mv = is_valid_method_view(endpoint)

        for verb in rule.methods.difference(ignore_verbs):
            if getattr(endpoint, 'methods', None) is not None:
                if verb in endpoint.methods:
                    verb = verb.lower()
                    if hasattr(endpoint.view_class, verb):
                        methods[verb] = getattr(endpoint.view_class, verb)
            else:
                methods[verb.lower()] = endpoint

        verbs = []
        for verb, method in methods.items():
            klass = method.__dict__.get('view_class', None)
            if not is_mv and klass and hasattr(klass, 'verb'):
                method = getattr(klass, 'verb', None)
            elif klass and hasattr(klass, 'dispatch_request'):
                method = getattr(klass, 'dispatch_request', None)
            if method is None:  # for MethodView
                method = getattr(klass, verb, None)

            if method is None:
                if is_mv:  # #76 Empty MethodViews
                    continue
                raise RuntimeError(
                    'Cannot detect view_func for rule {0}'.format(rule)
                )

            swag = {}
            swagged = False

            if getattr(method, 'specs_dict', None):
                definition = {}
                merge_specs(
                    swag,
                    {}
                )
                swag['definitions'] = definition
                swagged = True

            doc_summary, doc_description, doc_swag = parse_docstring(
                method, sanitizer, endpoint=rule.endpoint, verb=verb)

            if doc_swag:
                merge_specs(swag, doc_swag)
                swagged = True

            if swagged:
                if doc_summary:
                    swag['summary'] = doc_summary

                if doc_description:
                    swag['description'] = doc_description

                verbs.append((verb, swag))

        if verbs:
            specs.append((rule, verbs))

    return specs


class APIDocsView(MethodView):
    """
    The /apidocs
    """

    def __init__(self, *args, **kwargs):
        view_args = kwargs.pop('view_args', {})
        # self.config = view_args.get('config')
        super(APIDocsView, self).__init__(*args, **kwargs)

    def get(self):
        """
        The data under /apidocs
        json or Swagger UI
        """
        # return "ok"
        # base_endpoint = self.config.get('endpoint', 'flasgger')
        # print(base_endpoint)
        return render_template('flasgger/index.html')


class APISpecsView(MethodView):
    """
    The /apispec_1.json and other specs
    """

    def __init__(self, *args, **kwargs):
        self.loader = kwargs.pop('loader')
        super(APISpecsView, self).__init__(*args, **kwargs)

    def get(self):
        """
        The Swagger view get method outputs to /apispecs_1.json
        """
        return jsonify(self.loader())
        # return jsonify(name='张飞')


class ApiDoc(object):
    VERSION_STRING = "2.0.0"
    # 参数所在位置的枚举
    OP_PATH = 'path'
    OP_QUERY = 'query'
    OP_HEADER = 'header'
    OP_BODY = 'body'

    # 参数类型常量
    TYPE_STRING = 'string'
    TYPE_INT = 'integer'
    TYPE_BOOL = 'boolean'
    TYPE_FLOAT = 'number'
    TYPE_LIST = 'array'
    TYPE_DICT = 'object'

    DEFAULT_CONFIG = {
        "headers": [
        ],
        "specs": [
            {
                "endpoint": 'apispec_1',
                "route": '/apispec_1.json',
                "rule_filter": lambda rule: True,  # all in
                "model_filter": lambda tag: True,  # all in
            }
        ],
        "static_url_path": "/flasgger_static",
        # "static_folder": "static",  # must be set by user
        "swagger_ui": True,
        "specs_route": "/apidocs/"
    }

    deferred = []
    docs = []
    docs_json = None

    def __init__(self, app, sanitizer=None):
        self.config = self.DEFAULT_CONFIG.copy()
        self.load_config(app)
        self.app = app
        self.apispecs = {}  # cached apispecs
        self.definition_models = []  # not in app, so track here
        self.sanitizer = sanitizer or BR_SANITIZER
        cpts.init_components()

        if app:
            self.init_app(app)

    def init_app(self, app):
        self.register_view(app)

    def load_config(self, app):
        """
        Copy config from app
        """
        self.config.update(app.config.get('SWAGGER', {}))

    def register_view(self, app):

        blueprint = Blueprint(
            'flasgger',
            __name__,
            url_prefix='/api'
        )

        blueprint.add_url_rule(
            '/apidocs/',
            'apidocs',
            view_func=APIDocsView().as_view(
                'apidocs',
                view_args=dict(config=self.config)
            )
        )

        blueprint.add_url_rule(
            '/apispec_1.json',
            'apispec_1',
            view_func=APISpecsView.as_view(
                'apispec_1',
                loader=partial(
                    self.get_apispecs, endpoint='apispec_1')
            )
        )

        app.register_blueprint(blueprint)

    def get_url_mappings(self, rule_filter=None):
        """
        Returns all werkzeug rules
        """
        rule_filter = rule_filter or (lambda rule: True)
        app_rules = [
            rule for rule in current_app.url_map.iter_rules()
            if rule_filter(rule)
        ]
        return app_rules

    def get_def_models(self, definition_filter=None):
        """
        Used for class based definitions
        """
        model_filter = definition_filter or (lambda tag: True)
        return {
            definition.name: definition.obj
            for definition in self.definition_models
            if model_filter(definition)
        }

    def get_apispecs(self, endpoint='apispec_1'):
        if not self.app.debug and endpoint in self.apispecs:
            return self.apispecs[endpoint]

        spec = None
        for _spec in self.config['specs']:
            if _spec['endpoint'] == endpoint:
                spec = _spec
                break

        if not spec:
            raise RuntimeError(
                'Can`t find specs by endpoint {},'
                ' check your flasger`s config'.format(endpoint))

        data = {
            "openapi": "3.0.3",
            "servers": [
                {
                    "description": "USM API",
                    "url": "http://127.0.0.1:5051/"
                }
            ],
            "info": self.config.get('info') or {
                "version": spec.get(
                    'version', self.config.get('version', "1.0.0")
                ),
                "title": spec.get(
                    'title', self.config.get('title', "USM API")
                ),
                "description": spec.get(
                    'description', self.config.get('description',
                                                   "powered by Flasgger")
                ),
                "termsOfService": spec.get(
                    'termsOfService', self.config.get('termsOfService',
                                                      "/tos")
                ),
            },
            "paths": self.get_paths(),
            "definitions": self.config.get('definitions') or defaultdict(dict)
        }

        # print_dict(data)
        openapi_version = self.config.get('openapi')
        if openapi_version:
            data["openapi"] = openapi_version
        else:
            data["swagger"] = self.config.get('swagger') or self.config.get(
                'swagger_version', "3.0.3"
            )

        prefix_ids = self.config.get('prefix_ids')

        if self.config.get('host'):
            data['host'] = self.config.get('host')
        if self.config.get("basePath"):
            data["basePath"] = self.config.get('basePath')
        if self.config.get('schemes'):
            data['schemes'] = self.config.get('schemes')
        if self.config.get("securityDefinitions"):
            data["securityDefinitions"] = self.config.get(
                'securityDefinitions'
            )

        def is_openapi3():
            """
            Returns True if openapi_version is 3
            """
            return openapi_version and openapi_version.split('.')[0] == '3'

        # enable 'components' when openapi_version is 3.*.*
        if is_openapi3() and self.config.get("components"):
            data["components"] = self.config.get(
                'components'
            )

        paths = data['paths']
        definitions = data['definitions']
        ignore_verbs = set(
            self.config.get('ignore_verbs', ("HEAD", "OPTIONS"))
        )

        optional_fields = self.config.get('optional_fields') or OPTIONAL_FIELDS

        specs = get_specs(
            self.get_url_mappings(spec.get('rule_filter')), ignore_verbs,
            self.sanitizer
        )

        http_methods = ['get', 'post', 'put', 'delete']
        for rule, verbs in specs:
            operations = dict()
            for verb, swag in verbs:

                update_dict = swag.get('definitions', {})
                if type(update_dict) == list and type(update_dict[0]) == dict:
                    # pop, assert single element
                    update_dict, = update_dict
                definitions.update(update_dict)
                defs = []  # swag.get('definitions', [])
                defs += extract_definitions(
                    defs, endpoint=rule.endpoint, verb=verb,
                    prefix_ids=prefix_ids
                )

                params = swag.get('parameters', [])
                if verb in swag.keys():
                    verb_swag = swag.get(verb)
                    if len(params) == 0 and verb.lower() in http_methods:
                        params = verb_swag.get('parameters', [])

                defs += extract_definitions(params,
                                            endpoint=rule.endpoint,
                                            verb=verb,
                                            prefix_ids=prefix_ids)

                request_body = swag.get('requestBody')
                if request_body:
                    content = request_body.get("content", {})
                    extract_definitions(
                        list(content.values()),
                        endpoint=rule.endpoint,
                        verb=verb,
                        prefix_ids=prefix_ids
                    )

                callbacks = swag.get("callbacks", {})
                if callbacks:
                    callbacks = {
                        str(key): value
                        for key, value in callbacks.items()
                    }
                    extract_definitions(
                        list(callbacks.values()),
                        endpoint=rule.endpoint,
                        verb=verb,
                        prefix_ids=prefix_ids
                    )

                responses = None
                if 'responses' in swag:
                    responses = swag.get('responses', {})
                    responses = {
                        str(key): value
                        for key, value in responses.items()
                    }
                    if responses is not None:
                        defs = defs + extract_definitions(
                            responses.values(),
                            endpoint=rule.endpoint,
                            verb=verb,
                            prefix_ids=prefix_ids
                        )
                    for definition in defs:
                        if 'id' not in definition:
                            definitions.update(definition)
                            continue
                        def_id = definition.pop('id')
                        if def_id is not None:
                            definitions[def_id].update(definition)

                operation = {}
                if swag.get('summary'):
                    operation['summary'] = swag.get('summary')
                if swag.get('description'):
                    operation['description'] = swag.get('description')
                if request_body:
                    operation['requestBody'] = request_body
                if callbacks:
                    operation['callbacks'] = callbacks
                if responses:
                    operation['responses'] = responses
                # parameters - swagger ui dislikes empty parameter lists
                if len(params) > 0:
                    operation['parameters'] = params
                # other optionals
                for key in optional_fields:
                    if key in swag:
                        value = swag.get(key)
                        if key in ('produces', 'consumes'):
                            if not isinstance(value, (list, tuple)):
                                value = [value]

                        operation[key] = value
                operations[verb] = operation

            if len(operations):
                prefix = ''
                srule = '{0}{1}'.format(prefix, rule)
                for arg in re.findall('(<([^<>]*:)?([^<>]*)>)', srule):
                    srule = srule.replace(arg[0], '{%s}' % arg[2])

                for key, val in operations.items():
                    if srule not in paths:
                        paths[srule] = {}
                    if key in paths[srule]:
                        paths[srule][key].update(val)
                    else:
                        paths[srule][key] = val

        data.update(cpts.components)
        # print_dict(data)
        # print cpts.components

        self.apispecs[endpoint] = data

        return data

    @classmethod
    def get_unique(cls, rule):
        return "{}{}".format(rule, uuid.uuid4())

    @classmethod
    def get_type(cls, obj):
        if isinstance(obj, list):
            return 'array'
        elif isinstance(obj, str):
            return 'string'
        elif isinstance(obj, float):
            return 'number'
        elif isinstance(obj, bool):
            return 'boolean'
        elif isinstance(obj, int):
            return 'integer'
        else:
            return 'object'

    @classmethod
    def _convert_to_sg_type(cls, t):
        if t in ["str", 'basestring']:
            return cls.TYPE_STRING
        elif t in ['int', 'integer']:
            return cls.TYPE_INT
        elif t in ['bool', 'boolean']:
            return cls.TYPE_BOOL
        elif t in ['list', 'tuple', 'set']:
            return cls.TYPE_LIST
        elif t in ['float']:
            return cls.TYPE_FLOAT
        else:
            return cls.TYPE_DICT

    @classmethod
    def boxing(cls, key, name, op_in='body', require=True, params_type='string', default="string", limit=None):
        """
            通过参数生成api文档构造所需要的参数的模型的数据格式
        :param key: 参数的键
        :param name: 参数的描述信息
        :param op_in: 参数所在的部分，取值为path，query，header，body，
        :param require: 该参数是否是必须的，默认为必填
        :param params_type: 参数的类型，默认为string，取值有 integer，string，boolean，number，array，object，注意文件类型也是用string
        :param default: 参数的默认值，默认为None表示不显示默认值项
        :return:
        """
        return {'key': key, 'name': name, 'in': op_in, 'require': require, 'type': params_type, 'default': default,
                'limit': limit}

    @classmethod
    def api_doc(cls, summary, tag, description=None, resp=None, errors=[], **params):

        """
        :param rule: 路径, 注册的接口访问路径
        :param summary: 接口的描述信息, 默认传递空字符串表示接口不写入文档
        :param description: 接口的描述信息
        :param tag: 接口的分类标记
        :param params: 接口参数声明
        :param resp: 接口data域数据声明，数据返回的json格式，在数据的key之后需要以::间隔中文描述，
                    例如： {'name::用户名': 'jeff', 'age::年龄': 17, 'account::用户账号': '1234567', ...}
                    如果是最终值没有键的，可以传递一个tuple，表示第一位是描述，第二位是示例值
                    例如: ('用户名', 'name')

        :param errors: 接口错误声明，默认为None表示接口没有其他错误信息，传递示例：[(error_code, desc), ...]
        :return:
        """

        def wrapper(f):
            docs = []
            # 每个http方法都会产生一个文档块
            docs.append(
                {
                    'summary': summary,
                    'description': description if description else summary,
                    'params': cls._factory_params(params),
                    'resp': resp,
                    'errors': errors,
                    'tags': tag
                }
            )
            cls.docs.append((f, docs))
            return f

        return wrapper

    @classmethod
    def _factory_params(cls, params={}):
        rp = []
        for k, v in params.items():
            p = cls.boxing(key=k, name=k, op_in=v.op_in, require=v.mustbe, params_type=cls._convert_to_sg_type(v.mode),
                           limit=v.limit)
            rp.append(p)
        return rp

    @classmethod
    def resolve_resp_define(cls, resp, k=''):
        """
            解析API模型的数据结构生成文档需要的json格式
        :param resp:
        :param k:
        :return:
        """

        # 如果有声明键描述则按规则取出声明的描述
        if k and '::' in k:
            k, desc = k.split("::", 1)
        else:
            desc = ''

        if isinstance(resp, list):
            assert resp
            return {
                'type': 'array',
                'description': '列表数据' if not desc else desc,
                'items': cls.resolve_resp_define(resp[0])
            }
        elif isinstance(resp, dict):
            return {
                'type': 'object',
                'description': '对象数据' if not desc else desc,
                'properties': {sub_k.split("::", 1)[0]: cls.resolve_resp_define(sub_v, sub_k) for sub_k, sub_v in
                               resp.items()}
            }
        elif isinstance(resp, tuple):
            return cls.resolve_resp_define(resp[1], k='::{}'.format(resp[0]))
        else:
            return {
                'type': cls.get_type(resp),
                'description': '返回数据' if not desc else desc
            }

    def get_paths(self):

        # 填充文档的基本信息
        tags = set()
        paths = dict()

        methods_list = ['get', 'post', 'put', 'delete']

        for all_api in self.docs:

            # rule
            rule = find_url_rule(all_api[0])
            # 格式化请求地址
            url = path_param_regex.sub('{\g<1>}', rule.rule)
            infos = all_api[1]

            # request methods
            methods = rule.methods

            for m in methods:
                if m.lower() in methods_list:
                    for info in infos:
                        if not info['summary']:
                            continue

                        tag = info['tags']
                        tags.add(tag)
                        # 拼接完整的接口url
                        curr_url = '{}'.format(url)
                        if curr_url not in paths:
                            paths[curr_url] = dict()

                        # 创建每个接口对应下方法的文档json
                        curr_item = {
                            'tags': [tag],
                            'summary': info['summary'],
                            'description': info['description'],
                            'responses': {
                                '200': {
                                    'description': '{} success'.format(info['summary']),
                                    'content': {
                                        'application/json': {
                                            'schema': {
                                                '$ref': '#/components/schemas/Account'
                                            }
                                        }
                                    }
                                },
                                '401': {
                                    'description': '{} failed'.format(info['summary']),
                                    'content': {
                                        'application/json': {
                                            'schema': {
                                                '$ref': '#/components/schemas/Account'
                                            }
                                        }
                                    }
                                }
                            }
                        }

                        if info['params']:
                            # body的参数写法不一样，其他的都用同一种格式
                            for param in info['params']:
                                description = {'example': param['type']}
                                if param['limit'] is not None:
                                    if isinstance(param['limit'], tuple):
                                        description = {
                                            'example': "min:{} - max:{}".format(param['limit'][0], param['limit'][1])}
                                    elif isinstance(param['limit'], list):
                                        description = {'enum': param['limit']}

                                if param['in'] != self.OP_BODY:

                                    if not curr_item.get("parameters"):
                                        curr_item['parameters'] = []

                                    t = {
                                        'in': param['in'],
                                        'name': param['key'],
                                        'description': param['name'],
                                        'required': param['require'],
                                        'schema': {
                                            'type': param['type']
                                        }
                                    }
                                    t['schema'].update(description)

                                    curr_item['parameters'].append(t)


                                else:
                                    if not curr_item.get("requestBody"):
                                        curr_item['requestBody'] = {
                                            'required': True,
                                            'content': {
                                                'application/json': {
                                                    'schema': {
                                                        'type': 'object',
                                                        'required': [

                                                        ],
                                                        'properties': {

                                                        }
                                                    }
                                                }
                                            }
                                        }

                                    if param['require']:
                                        curr_item['requestBody']['content']['application/json']['schema'][
                                            'required'].append(param['key'])

                                    curr_item['requestBody']['content']['application/json']['schema'][
                                        'properties'].update({
                                        param['key']: {
                                            "type": param['type'],
                                            "example": param['default'],
                                            "description": param['name']
                                        }
                                    })
                                    curr_item['requestBody']['content']['application/json']['schema']['properties'][
                                        param['key']].update(description)

                        if info.get('resp'):
                            if info['resp'].startswith("#/"):
                                curr_item['responses']['200']['content']['application/json'][
                                    'schema'] = {"&ref": info['resp']}
                            else:
                                curr_item['responses']['200']['content']['application/json'][
                                    'schema'] = info['resp']

                        if info['errors']:
                            for err_code, err_desc in info['errors']:
                                curr_item['responses'][str(err_code)] = {'description': err_desc}

                        paths[curr_url][m.lower()] = curr_item

        return paths


class ApiNode(object):
    def __init__(self, mode, mustbe=True, limit=None, op_in=ApiDoc.OP_BODY):
        self.mode = mode
        self.mustbe = mustbe
        self.limit = limit
        self.op_in = op_in
